Leaflet Blog in Deno Fresh
at main 7.1 kB view raw
1import { Handlers, PageProps } from "$fresh/server.ts"; 2import { Layout } from "../../islands/layout.tsx"; 3import { PostInfo } from "../../components/post-info.tsx"; 4import { Title } from "../../components/typography.tsx"; 5import { getPost } from "../../lib/api.ts"; 6import { Head } from "$fresh/runtime.ts"; 7import { TextBlock } from "../../components/TextBlock.tsx"; 8import { 9 PubLeafletBlocksHeader, 10 PubLeafletBlocksImage, 11 PubLeafletBlocksText, 12 PubLeafletBlocksUnorderedList, 13 PubLeafletPagesLinearDocument, 14} from "npm:@atcute/leaflet"; 15import { h } from "preact"; 16 17interface Post { 18 uri: string; 19 value: { 20 title?: string; 21 description?: string; 22 pages?: PubLeafletPagesLinearDocument.Main[]; 23 publishedAt?: string; 24 }; 25} 26 27export const handler: Handlers<Post> = { 28 async GET(_req, ctx) { 29 try { 30 const { slug } = ctx.params; 31 const post = await getPost(slug); 32 return ctx.render(post); 33 } catch (error) { 34 console.error("Error fetching post:", error); 35 return new Response("Post not found", { status: 404 }); 36 } 37 }, 38}; 39 40function Block({ 41 block, 42 did, 43 isList, 44}: { 45 block: PubLeafletPagesLinearDocument.Block; 46 did: string; 47 isList?: boolean; 48}) { 49 let b = block; 50 51 let className = ` 52 postBlockWrapper 53 pt-1 54 ${ 55 isList 56 ? "isListItem pb-0 " 57 : "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3" 58 } 59 ${ 60 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" 61 ? "text-right" 62 : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 63 ? "text-center" 64 : "" 65 } 66 `; 67 68 if (b.block.$type === "pub.leaflet.blocks.unorderedList") { 69 return ( 70 <ul className="-ml-[1px] sm:ml-[9px] pb-2"> 71 {b.block.children.map((child, index) => ( 72 <ListItem item={child} did={did} key={index} className={className} /> 73 ))} 74 </ul> 75 ); 76 } 77 78 if (b.block.$type === "pub.leaflet.blocks.image") { 79 const imageBlock = b.block as PubLeafletBlocksImage.Main; 80 const image = imageBlock.image as { ref: { $link: string } }; 81 const alt = imageBlock.alt || ""; 82 const aspect = imageBlock.aspectRatio; 83 let width = aspect?.width; 84 let height = aspect?.height; 85 // Fallback to default size if not provided 86 if (!width) width = 600; 87 if (!height) height = 400; 88 return ( 89 <img 90 src={`/api/atproto_images?did=${did}&cid=${image.ref.$link}`} 91 alt={alt} 92 width={width} 93 height={height} 94 className={`!pt-3 sm:!pt-4 ${className}`} 95 style={{ 96 aspectRatio: width && height ? `${width} / ${height}` : undefined, 97 }} 98 /> 99 ); 100 } 101 102 if (b.block.$type === "pub.leaflet.blocks.text") { 103 return ( 104 <div className={` ${className}`}> 105 <TextBlock facets={b.block.facets} plaintext={b.block.plaintext} /> 106 </div> 107 ); 108 } 109 110 if (b.block.$type === "pub.leaflet.blocks.header") { 111 const header = b.block as PubLeafletBlocksHeader.Main; 112 const level = header.level || 1; 113 const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements; 114 // Add heading styles based on level 115 let headingStyle = 116 "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap "; 117 switch (level) { 118 case 1: 119 headingStyle += "text-3xl lg:text-4xl"; 120 break; 121 case 2: 122 headingStyle += "text-3xl border-b pb-2 mb-6"; 123 break; 124 case 3: 125 headingStyle += "text-2xl"; 126 break; 127 case 4: 128 headingStyle += "text-xl"; 129 break; 130 case 5: 131 headingStyle += "text-lg"; 132 break; 133 case 6: 134 headingStyle += "text-base"; 135 break; 136 default: 137 headingStyle += "text-2xl"; 138 } 139 return ( 140 <Tag className={headingStyle + " " + className}> 141 <TextBlock plaintext={header.plaintext} facets={header.facets} /> 142 </Tag> 143 ); 144 } 145 146 return null; 147} 148 149function ListItem(props: { 150 item: PubLeafletBlocksUnorderedList.Main["children"][number]; 151 did: string; 152 className?: string; 153}) { 154 return ( 155 <li className={`!pb-0 flex flex-row gap-2`}> 156 <div 157 className={`listMarker shrink-0 mx-2 z-[1] mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`} 158 /> 159 <div className="flex flex-col"> 160 <Block block={{ block: props.item.content }} did={props.did} isList /> 161 {props.item.children?.length 162 ? ( 163 <ul className="-ml-[7px] sm:ml-[7px]"> 164 {props.item.children.map((child, index) => ( 165 <ListItem 166 item={child} 167 did={props.did} 168 key={index} 169 className={props.className} 170 /> 171 ))} 172 </ul> 173 ) 174 : null} 175 </div> 176 </li> 177 ); 178} 179 180export default function BlogPage({ data: post }: PageProps<Post>) { 181 if (!post) { 182 return <div>Post not found</div>; 183 } 184 185 const firstPage = post.value.pages?.[0]; 186 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 187 if (firstPage?.$type === "pub.leaflet.pages.linearDocument") { 188 blocks = firstPage.blocks || []; 189 } 190 // Deduplicate blocks by $type and plaintext 191 const seen = new Set(); 192 const uniqueBlocks = blocks.filter((b) => { 193 const key = b.block.$type + "|" + ((b.block as any).plaintext || ""); 194 if (seen.has(key)) return false; 195 seen.add(key); 196 return true; 197 }); 198 199 const content = uniqueBlocks 200 .filter((b) => b.block.$type === "pub.leaflet.blocks.text") 201 .map((b) => (b.block as PubLeafletBlocksText.Main).plaintext) 202 .join(" "); 203 204 return ( 205 <> 206 <Head> 207 <title>{post.value.title} knotbin</title> 208 <meta 209 name="description" 210 content={post.value.description || "by Roscoe Rubin-Rottenberg"} 211 /> 212 </Head> 213 214 <Layout> 215 <div class="p-8 pb-20 gap-16 sm:p-20"> 216 <link rel="alternate" href={post.uri} /> 217 <div class="max-w-[600px] mx-auto"> 218 <article class="w-full space-y-8"> 219 <div class="space-y-4 w-full"> 220 <Title>{post.value.title || "Untitled"}</Title> 221 {post.value.description && ( 222 <p class="text-xl italic md:text-2xl font-serif leading-relaxed max-w-prose"> 223 {post.value.description} 224 </p> 225 )} 226 <PostInfo 227 content={content} 228 createdAt={post.value.publishedAt || new Date().toISOString()} 229 includeAuthor 230 className="text-sm" 231 /> 232 <div class="diagonal-pattern w-full h-3" /> 233 </div> 234 <div class="postContent flex flex-col"> 235 {uniqueBlocks.map((block, index) => ( 236 <Block 237 block={block} 238 did={post.uri.split("/")[2]} 239 key={index} 240 /> 241 ))} 242 </div> 243 </article> 244 </div> 245 </div> 246 </Layout> 247 </> 248 ); 249}